/*
 * Copyright 2019. Google LLC
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     https://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.google.android.apps.santatracker.doodles.shared.physics;

import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import com.google.android.apps.santatracker.doodles.shared.Constants;
import com.google.android.apps.santatracker.doodles.shared.Vector2D;
import java.util.ArrayList;
import java.util.List;
import org.json.JSONArray;
import org.json.JSONException;
import org.json.JSONObject;

/**
 * A general polygon class (either concave or convex) which can tell whether or not a point is
 * inside of it.
 *
 * <p>
 *
 * <p>NOTE: vertex winding order affects the normals of the line segments, and can affect things
 * like collisions. A non-inverted (normals pointed out) polygon should have its vertices wound
 * clockwise.
 */
public class Polygon {
    private static final String TAG = Polygon.class.getSimpleName();
    private static final float EPSILON = 0.0001f;
    private static final float VERTEX_RADIUS = 10;
    public List<Vector2D> vertices;
    public List<Vector2D> normals;
    public Vector2D min;
    public Vector2D max;
    private Paint vertexPaint;
    private Paint midpointPaint;
    private Paint linePaint;
    private boolean isInverted;

    public Polygon(List<Vector2D> vertices) {
        this.vertices = vertices;
        min = Vector2D.get(0, 0);
        max = Vector2D.get(0, 0);

        vertexPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        vertexPaint.setColor(Color.RED);

        midpointPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        midpointPaint.setColor(Color.GREEN);
        midpointPaint.setAlpha(100);

        linePaint = new Paint(Paint.ANTI_ALIAS_FLAG);
        linePaint.setColor(Color.WHITE);
        linePaint.setStrokeWidth(5);

        updateExtents();
        updateInversionStatus();
        calculateNormals();
    }

    public static Polygon fromJSON(JSONArray json) throws JSONException {
        List<Vector2D> vertices = new ArrayList<>();
        for (int i = 0; i < json.length(); i++) {
            JSONObject vertexJson = json.getJSONObject(i);
            Vector2D vertex =
                    Vector2D.get(
                            (float) vertexJson.getDouble("x"), (float) vertexJson.getDouble("y"));
            vertices.add(vertex);
        }
        return new Polygon(vertices);
    }

    public void updateExtents() {
        min.set(this.vertices.get(0));
        max.set(this.vertices.get(0));
        for (int i = 0; i < vertices.size(); i++) {
            Vector2D point = vertices.get(i);
            min.x = Math.min(min.x, point.x);
            min.y = Math.min(min.y, point.y);
            max.x = Math.max(max.x, point.x);
            max.y = Math.max(max.y, point.y);
        }
    }

    public void calculateNormals() {
        normals = new ArrayList<>();
        for (int i = 0; i < vertices.size(); i++) {
            Vector2D start = vertices.get(i);
            Vector2D end = vertices.get((i + 1) % vertices.size());
            normals.add(Vector2D.get(end).subtract(start).toNormal());
        }
    }

    public float getWidth() {
        return max.x - min.x;
    }

    public float getHeight() {
        return max.y - min.y;
    }

    public void moveTo(float x, float y) {
        float deltaX = x - min.x;
        float deltaY = y - min.y;
        move(deltaX, deltaY);
    }

    public void move(float x, float y) {
        for (int i = 0; i < vertices.size(); i++) {
            Vector2D vertex = vertices.get(i);
            vertex.x += x;
            vertex.y += y;
        }
        // Rather than update the extents by checking all of the vertices here, we can just update
        // them
        // manually (they will move by the same amount as the rest of the vertices).
        min.x += x;
        min.y += y;
        max.x += x;
        max.y += y;
    }

    public void moveVertex(int index, Vector2D delta) {
        Vector2D vertex = vertices.get(index);
        vertex.x += delta.x;
        vertex.y += delta.y;
        updateExtents();
        updateInversionStatus();
    }

    public void addVertexAfter(int index) {
        int nextIndex = index < vertices.size() - 1 ? index + 1 : 0;
        Vector2D newVertex = Util.getMidpoint(vertices.get(index), vertices.get(nextIndex));
        vertices.add(nextIndex, newVertex);
        updateExtents();
        calculateNormals();
    }

    public void removeVertexAt(int index) {
        vertices.remove(index);
        updateExtents();
    }

    /**
     * Return the index of the vertex selected by the given point.
     *
     * @param point the point at which to check for a selected vertex.
     * @param scale the scale of the world, for slackening the selection radius if needed.
     * @return the index of the selected vertex, or -1 if no vertex was selected.
     */
    public int getSelectedIndex(Vector2D point, float scale) {
        for (int i = 0; i < vertices.size(); i++) {
            Vector2D vertex = vertices.get(i);
            if (point.distanceTo(vertex)
                    < Math.max(Constants.SELECTION_RADIUS, Constants.SELECTION_RADIUS / scale)) {
                return i;
            }
        }
        return -1;
    }

    public int getMidpointIndex(Vector2D point, float scale) {
        for (int i = 0; i < vertices.size(); i++) {
            Vector2D start = vertices.get(i);
            Vector2D end = vertices.get(i < vertices.size() - 1 ? i + 1 : 0);
            if (point.distanceTo(Util.getMidpoint(start, end))
                    < Math.max(Constants.SELECTION_RADIUS, Constants.SELECTION_RADIUS / scale)) {
                return i;
            }
        }
        return -1;
    }

    /**
     * Return whether or not this polygon is inverted (i.e., whether or not the polygon has a normal
     * which points inwards.
     *
     * @return true if the polygon is inverted, false otherwise.
     */
    public boolean isInverted() {
        return isInverted;
    }

    /**
     * Calculate whether or not this polygon is inverted. This checks to see if the point which is
     * one unit in the normal direction on the polygon's first segment is within the bounds of the
     * polygon. If this is the case, then the normal points inwards and the polygon is inverted.
     * Otherwise, the polygon is not inverted.
     *
     * <p>
     *
     * <p>Note: This doesn't deal with polygons which are partially inverted. These sorts of
     * polygons should be avoided, as they will break this function.
     */
    private void updateInversionStatus() {
        Vector2D start = vertices.get(0);
        Vector2D end = vertices.get(1);
        Vector2D midpoint = Util.getMidpoint(start, end);
        Vector2D normal = Vector2D.get(end).subtract(start).toNormal().scale(0.1f);

        if (contains(midpoint.add(normal))) {
            isInverted = true;
        } else {
            isInverted = false;
        }
        normal.release();
        midpoint.release();
    }

    /**
     * Return whether or not this polygon's collision boundaries contain a given point. A polygon
     * contains a point iff the point is contained within the polygon's collision boundaries,
     * regardless of the direction of the polygon's normals.
     *
     * @param point the point to check
     * @return true if this polygon contains the point, false otherwise.
     */
    public boolean contains(Vector2D point) {
        // If the bounding box doesn't contain the point, we don't need to do any more calculations.
        if (!Util.pointIsWithinBounds(min, max, point)) {
            return false;
        }

        // Cast vertical ray from point to outside polygon and counting crossings. Point is in
        // polygon
        // iff number of edges crossed is odd.

        // Find a Y value that's definitely outside the polygon.
        float maxY = max.y + 1;
        Vector2D outsidePoint = Vector2D.get(point.x, maxY);

        // Check how many edges lie between (p.x, p.y) and (p.x, maxY).
        boolean inside = false;
        for (int i = 0; i < vertices.size(); i++) {
            Vector2D p1 = vertices.get(i);
            Vector2D p2;
            if (i < vertices.size() - 1) {
                p2 = vertices.get(i + 1);
            } else {
                p2 = vertices.get(0);
            }

            // First check endpoints. Hitting left-most point counts, hitting right-most
            // doesn't (to weed out case where ray hits 2 lines at their joining vertex)    }
            if (p1.y >= point.y && Math.abs(p1.x - point.x) <= EPSILON) {
                if (p2.x >= point.x) {
                    inside = !inside;
                }
                continue;
            } else if (p2.y >= point.y && Math.abs(p2.x - point.x) <= EPSILON) {
                if (p1.x >= point.x) {
                    inside = !inside;
                }
                continue;
            }

            // Now check for intersection.
            if (Util.lineSegmentIntersectsLineSegment(p1, p2, point, outsidePoint)) {
                inside = !inside;
            }
        }
        outsidePoint.release();
        return inside;
    }

    public LineSegment getIntersectingLineSegment(Vector2D p, Vector2D q) {
        for (int i = 0; i < vertices.size(); i++) {
            Vector2D p1 = vertices.get(i);
            Vector2D p2;
            if (i < vertices.size() - 1) {
                p2 = vertices.get(i + 1);
            } else {
                p2 = vertices.get(0);
            }

            if (Util.lineSegmentIntersectsLineSegment(p1, p2, p, q)) {
                return new LineSegment(p1, p2);
            }
        }
        return null;
    }

    public void draw(Canvas canvas) {
        for (int i = 0; i < vertices.size(); i++) {
            Vector2D start = vertices.get(i);
            Vector2D end;
            if (i < vertices.size() - 1) {
                end = vertices.get(i + 1);
            } else {
                end = vertices.get(0);
            }
            Vector2D midpoint = Util.getMidpoint(start, end);
            Vector2D normal = Vector2D.get(end).subtract(start).toNormal();

            canvas.drawCircle(start.x, start.y, VERTEX_RADIUS, vertexPaint);
            canvas.drawLine(start.x, start.y, end.x, end.y, linePaint);
            canvas.drawCircle(midpoint.x, midpoint.y, VERTEX_RADIUS / 2, midpointPaint);
            canvas.drawLine(
                    midpoint.x,
                    midpoint.y,
                    midpoint.x + normal.x * 20,
                    midpoint.y + normal.y * 20,
                    linePaint);

            midpoint.release();
            normal.release();
        }
    }

    public void setPaintColors(int vertexColor, int lineColor, int midpointColor) {
        vertexPaint.setColor(vertexColor);
        linePaint.setColor(lineColor);
        midpointPaint.setColor(midpointColor);
    }

    public JSONArray toJSON() throws JSONException {
        JSONArray json = new JSONArray();
        for (int i = 0; i < vertices.size(); i++) {
            JSONObject vertexJson = new JSONObject();
            Vector2D vertex = vertices.get(i);
            vertexJson.put("x", (double) vertex.x);
            vertexJson.put("y", (double) vertex.y);
            json.put(vertexJson);
        }
        return json;
    }

    /**
     * A class to specify the starting and ending point of a line segment. Currently only used in
     * determining which line segment is being collided with, so we can determine the normal vector.
     */
    public static class LineSegment {
        public Vector2D start;
        public Vector2D end;

        public LineSegment(Vector2D start, Vector2D end) {
            this.start = start;
            this.end = end;
        }

        public Vector2D getDirection() {
            return Vector2D.get(end).subtract(start);
        }
    }
}
